However, we all know that technologies come and go. The Angular team, for example, is currently working on CLI support for the bundler esbuild [1], which looks very promising, particularly in terms of build performance.
This raises the question of how to implement the tried-and-tested mental model of module federation independently of webpack in order to future-proof your own microfrontend architecture. This article provides an answer based on import maps that now work with all major browsers — either natively, as in Chrome or Edge, or by adding a polyfill. The examples shown can be found in [2].
Mental model behind module federation
Before we get into import maps, I would like to talk about the mental model of module federation, which we will try to emulate later with import maps (Fig. 1).
This mental model, among other things, defines host applications that load modules of separately built and separately deployed remote applications. These modules can export any JavaScript construct: Examples include functions, components, and data structures such as Angular modules or routes. The host in the world of microfrontends is a shell, and the remote is the microfrontend itself.
The host loads the provided modules with a dynamic import or — if the remotes are not known in advance — via a low-level API provided by webpack at runtime. The latter is indicated by the auxiliary function loadRemoteModule. This function points to both the name of the published module (exposedModule) and a remote entry (remoteEntry).
iJS Newsletter
Keep up with JavaScript’s latest news!
The remote entry is a JavaScript file that webpack generates during bundling that contains metadata about the remote. This metadata, among other things, informs the remotes and the host about the dependencies to be shared at runtime. These dependencies must be specified in the configuration under shared.
For example, in Figure 1, it is specified that @angular/core should be shared. This means that even if @angular/core is used in multiple remotes or the host, it is only loaded once.
When sharing dependencies, version conflicts can, of course, occur. Long-time Windows users may recall this issue being referred to as DLL Hell. Fortunately, module federation comes with several strategies for avoiding conflicts (Box: “Module Federation and version conflicts”).
Import maps — an underestimated browser technology
Listing 1 shows an example of an import map that checks whether a holiday results in an extended weekend or a bridging day (an extra day taken off after a bank holiday, Ed.).
Listing 1
The well-known library date-fns is used for this check. Furthermore, the solution is dependent on the two auxiliary functions isLongWeekend and isBridgingDay, which are located in other modules. The special thing about this is that the example runs directly in the browser, as the script tags indicate. Now the question is how the browser resolves the import statements. Typically, this is handled by a build tool that places the individual source code files in one or more bundles. However, in this case, the browser keeps track of individual imports at runtime. It takes the necessary information from an import map (Listing 2).
Listing 2
The import map maps the names from the import statements to concrete JavaScript files. date-fns.js is a bundle that was previously created with esbuild and contains only the date-fns library. This saves the browser from having to load the numerous date-fns files individually.
Import maps, as mentioned earlier, are currently supported directly by some browsers. These include, among others, Chrome and Edge. For others, a polyfill can be used to retrofit this browser technology. A popular polyfill created for production use can be found at [3].
Avoiding version conflicts with scopes
To resolve version conflicts, import maps provide so-called scopes. Listing 3, for example, uses a scope for the is-bridging-day.mjs file.
Listing 3
The scope shown specifies that the name date-fns within is-bridging-day.mjs refers to the other-date-fns.js. In other files, however, date-fns still points to the date-fns.js file specified under imports. At runtime, the dev tools show that the browser actually loads both versions (Fig. 2).
If, on the other hand, several scopes refer to the same file, the browser loads it only once. When transferring the mental model from module federation to import maps, you could now set up a separate scope for each remote and use the file names stored there to determine whether the remote loads its own version or reuses the version of another remote.
Dynamic import maps and version negotiation
The import maps considered so far were self-written. This is not practical, however, for large applications with a large number of dependencies and remotes. Instead, it is a good idea to generate the import map from metadata (Listing 4).
Listing 4
This simplified example assumes that the host knows its own metadata (myDateFns) and has loaded the metadata of the remote (otherDateFns). This is used to generate the import map, which is initially just a JavaScript object.
There is a separate scope for the remote. The negotiate function determines whether the remote gets its own version of date-fns or reuses the host’s. A slightly more advanced version of this method could implement the strategies used by module federation to resolve version conflicts (see box “Module Federation and version conflicts”). This example could be expanded further to derive the entire import map from metadata.
In the end, the example creates a script tag for the import map and inserts it into the page. For this to work, no other script tags with type=”module” may be placed before it. This restriction avoids a chicken-and-egg problem in browser-native implementations. The polyfill mentioned above, however, does not see this aspect quite so narrowly in the so-called shim mode and also allows for the subsequent insertion of import maps.
EVERYTHING AROUND ANGULAR
The iJS Angular track
Externals, but with imports please!
Thanks to import maps, the browser now directly resolves the import statements that lead to shared dependencies and remotes. However, this also means that the bundler must not perform this task in advance. Technically, the bundler must be instructed to simply copy the corresponding import statements into the bundle one-to-one, rather than including the referenced files in the bundle as well.
Most bundlers refer to such unresolved dependencies as externals, and usually such externals can be specified via the bundler’s configuration. In the case of esbuild, they are passed as an array (Listing 5).
Listing 5
Interim conclusion: Promising, but low-level
The previous sections have shown that import maps provide the necessary building blocks for emulating the mental model of module federation. They do not, however, provide enough abstraction. To be useful for really large applications, we need a superstructure that handles, among other things, the following tasks:
- providing and loading of metadata
- separate bundling of shared dependencies and remotes
- consideration of the Angular compiler
- generation of an import map including scopes for remotes
- handling version conflicts
- loading of remotes
All these tasks should also be able to be configured, as with module federation, via the simplest possible configuration. Aside from that, in the Angular world, we need a CLI integration that sets up the solution with ng add or ng generate and triggers it when ng serve, ng build, and so on are called.
The solution: native federation
With the package native federation [4] I want to fulfill the requirements shown in the previous section. It is open source, based on the concepts presented in this article, and provides the same API as the module federation plug-in [5], allowing existing knowledge to be reused. Listing 6 shows an example of a native-federation configuration.
Listing 6
Apart from the package name referenced at the beginning with require, this configuration corresponds to the structure known from the module federation plug-in [5]. The auxiliary function shareAll shares all dependencies, which can be found in the package.json under dependencies. There is a loadRemoteModule helper function for loading remotes:
Despite the fact that the current implementation is based on Angular and esbuild, the design is made to interact with any SPA frameworks and bundlers. The readme of the package and the examples linked there show that running module federation without webpack will be very simple in the future.
The package serves as insurance in case we need to work without webpack in the future. This option seems essential, especially since microfrontends are used for large and long-lived projects.
In conclusion
Import maps provide all the necessary building blocks to recreate the mental model of module federation independent of individual bundlers: They allow direct loading of remotes and shared dependencies, can be dynamically generated using metadata, and allow version conflicts to be resolved thanks to scopes.
However, since they provide too little abstraction, we need a superstructure that makes these capabilities more accessible. This superstructure should ideally provide the same API as the module fedation plug-in [5], allowing us to continue to rely on existing knowledge.
Native federation [4] takes on this task. It shows that with module federation [6,] we are not tied to webpack in the long run and are thus future-proof.
References
[2] https://github.com/manfredsteyer/import-maps-101.git
[3] https://github.com/guybedford/es-module-shims
[4] https://www.npmjs.com/package/@angular-architects/native-federation
[5] https://www.npmjs.com/package/@angular-architects/module-federation